iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Modern Web

《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記系列 第 6

Day 6|型別的騙局:`ConfigService.get<number>()` 拿到的為什麼還是字串?

  • 分享至 

  • xImage
  •  

在開發 NestJS 應用時,環境變數是抽離系統組態與機密資訊的常見做法。為了獲得良好的開發體驗與型別提示,我們常使用官方提供的 ConfigService,並透過泛型告訴 TypeScript 這個設定值預期是什麼型別:

const port = configService.get<number>('PORT');

看著編輯器推論出的 number 型別,加上 ESLint 完全沒有跳出任何警告,你可能會安心地繼續往下寫。直到你拿這個變數做數學運算,甚至直接拿去綁定連接埠時,程式卻出現了令人摸不著頭緒的 Bug。

這就是 NestJS 開發中很容易踩到的環境變數型別騙局:編輯器明明告訴你它是 number,為什麼跑起來卻還是 string

問題怎麼發生?

為了聚焦在環境變數的解析,這個範例不需要建立完整的 HTTP Server。我們改用 NestJS 的 Application Context 來啟動模組與 DI 容器,在控制台印出驗證結果後即自動關閉。

先安裝需要的套件:

npm install @nestjs/config joi

建立 .env

PORT=3000

接著註冊 ConfigModule,暫時不做任何驗證:

// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [ConfigModule.forRoot()],
})
export class AppModule {}

在應用程式啟動階段,同時觀察原始環境變數與 ConfigService 取得的結果:

// main.ts
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.createApplicationContext(AppModule);
  const configService = app.get(ConfigService);

  const rawPort = process.env.PORT!;
  const configPort = configService.get<number>('PORT')!;

  console.table([
    {
      source: 'process.env.PORT',
      value: rawPort,
      runtimeType: typeof rawPort,
      nextPort: rawPort + 1,
    },
    {
      source: "configService.get<number>('PORT')",
      value: configPort,
      runtimeType: typeof configPort,
      nextPort: configPort + 1,
    },
  ]);

  await app.close();
}

void bootstrap();

這裡的 ! 只是在已知 .env 有提供 PORT 的前提下,排除 TypeScript 推論出的 undefined,它同樣不會產生任何執行期轉型。

執行後會看到:

https://ithelp.ithome.com.tw/upload/images/20260920/20184306AiKB4kujfh.png

兩列的執行期型別都是 stringget<number>() 沒有替設定值做任何轉換。

根因一:環境變數進入 Node.js 後是文字

.env 雖然看起來像是在定義變數型別,但對 Node.js 來說,它本質上就只是一份純文字檔案。不論你在裡面寫了什麼,載入後通通都是字串:

PORT=3000
DEBUG=false

讀進 process.env 後,得到的其實是:

process.env.PORT === '3000';
process.env.DEBUG === 'false';

這會帶來意想不到的陷阱——在 JavaScript 中,非空字串 'false' 依然是一個 truthy 值。 如果你寫了 if (process.env.DEBUG),條件判斷結果將永遠為 true,完全與直覺相反。

根因二:泛型在 JavaScript 執行期不存在

get<number>() 裡的 number 是呼叫端提供的泛型型別參數。它會影響 TypeScript 對回傳值的靜態推論,但不會產生執行期的轉型或驗證程式碼。

這段 TypeScript:

const port = configService.get<number>('PORT');

編譯成 JavaScript 後,概念上只剩:

const port = configService.get('PORT');

<number> 已經消失了。更精確地說,它不是對 ConfigService 下達「請轉成 number」的指令,而是開發者向 TypeScript 做出的承諾:「我相信這裡會拿到 number。」如果外部資料沒有履行這份承諾,TypeScript 並不知道。

在目前沒有使用自訂設定(Custom Configuration)或驗證結構(Validation Schema)的情況下,ConfigService.get() 取得的 PORT 仍是原始的環境變數字串。所以真正執行的是:

'3000' + 1; // '30001'

排雷指南

解法一:Schema 驗證工具

與其每次取值時才各自轉型,更適合在設定載入應用程式時就統一完成驗證與轉型。

NestJS 的 ConfigModule 支援透過 validationSchema 搭配 Schema 驗證工具。本文使用 Joi 示範:

// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import * as Joi from 'joi';

@Module({
  imports: [
    ConfigModule.forRoot({
      validationSchema: Joi.object({
        PORT: Joi.number().port().default(3000),
      }),
    }),
  ],
})
export class AppModule {}

取值的程式完全不用改:

const configPort = configService.get<number>('PORT');

再次執行:

https://ithelp.ithome.com.tw/upload/images/20260920/20184306cPtDt2oaRF.png

觀察輸出結果,第二列的 runtimeType 已經成功轉為 numbernextPort 也順利計算出正確的數字結果 3001

這背後的轉變,源自 ConfigModule 在啟動階段完成的四個步驟:

  1. 載入原始設定ConfigModule.env 或系統環境變數讀入原始字串(如 '3000')。
  2. Schema 驗證與轉型:Joi 依照規則進行檢查,在預設允許型別轉換(convert: true)的情況下,將 '3000' 解析成數字 3000
  3. 保存驗證結果ConfigModule 會保存經過 Schema 驗證與轉型後的環境變數,讓後續的 ConfigService 可以取得轉換後的值。
  4. 讀取安全資料:當你呼叫 configService.get('PORT') 時,它會優先回傳剛才保存好的安全數值,而不是直接去翻原始的 process.env

引進 Schema 驗證機制不只是為了自動轉型,更重要的價值在於建立快速失敗(Fail-fast)機制——拒絕帶著不符合 Schema 規則的設定啟動系統。

若將 .env 改為非法值(如 PORT=not-a-number 或超出範圍的 PORT=70000),ConfigModule 在啟動階段就會立刻拋出例外並終止程序。

沒有 Schema 驗證時,配置錯誤就像一顆隱形炸彈,往往要等到使用者觸發特定功能時才會爆發;而引入 Schema 後,應用程式在連接資料庫、監聽埠號或處理請求前就提早攔截問題,讓配置錯誤在第一時間被定位。

在上面設定的 Schema 中,三個 Joi 規則各自扮演了關鍵角色:

規則 作用
Joi.number() 在 Joi 預設 convert 開啟的情況下,將可轉換的數字字串轉成 number,無法轉換時回報錯誤
.port() 限制數值必須在合法的 Port 範圍(0 ~ 65535)內
.default(3000) 缺少 PORT 時補上 number 型別的預設值

💡 補充說明:
本文以 Joi 作為示範,但在最新的 NestJS v12 官方文件中,已經建議改用 Zod等現代 Standard Schema library 來處理 schema 驗證。無論使用 Joi 還是 Zod,核心目的都相同:在 ConfigModule 載入與初始化設定時建立一道驗證邊界,先完成執行期轉型與驗證,再讓其他應用程式程式碼取得設定值。

解法二:自訂驗證函數

除了 Joi,NestJS 也在 ConfigModule 提供了自訂 validate() 函式,供我們搭配 class-validatorclass-transformer 使用:

// env.validation.ts
import { plainToInstance } from 'class-transformer';
import { IsInt, Max, Min, validateSync } from 'class-validator';

class EnvironmentVariables {
  @IsInt()
  @Min(0)
  @Max(65535)
  PORT!: number;
}

export function validateEnvironment(config: Record<string, unknown>) {
  const rawPort = config.PORT ?? 3000;

  if (typeof rawPort === 'string' && rawPort.trim() === '') {
    throw new Error('PORT must be a number');
  }

  
  const validatedConfig = plainToInstance(
    EnvironmentVariables,
    { ...config, PORT: rawPort },
    { enableImplicitConversion: true }, // 利用 enableImplicitConversion 執行轉型
  );

  const errors = validateSync(validatedConfig, {
    skipMissingProperties: false,
  });

  if (errors.length > 0) {
    throw new Error(errors.toString());
  }

  return {
    ...config,
    PORT: validatedConfig.PORT, // 回傳處理後的結果
  };
}

接著將驗證函式傳入 ConfigModule

import { validateEnvironment } from './env.validation';

ConfigModule.forRoot({
  validate: validateEnvironment, // 指定自訂 validate 函式
});

這種做法的核心關鍵在於:validate() 函式必須將轉型後的物件回傳ConfigModule 會以該函式回傳的物件作為最終儲存依據,而不是原始的 process.env

⚠️ 注意
enableImplicitConversion 很方便,但不代表所有環境變數都能安全依賴隱式轉型。尤其布林值需要明確定義解析規則,例如 'false' 不應直接交給 JavaScript 的 Boolean()

陷阱一:預設值與 getOrThrow() 的權責邊界

發現問題後,有些人可能會嘗試這兩種防禦寫法:

configService.get<number>('PORT', 3000);
configService.getOrThrow<number>('PORT');

這兩種寫法雖然實用,但它們解決的是「是否存在」,而非「型別是否正確」:

  • 預設值:僅在 PORT 未設定(undefined)時生效。只要 .env 有填寫 PORT=3000,字串 '3000' 就會直接蓋掉預設值,不會因為預設值傳了數字就自動轉型。
  • getOrThrow():僅保證設定存在。只要不是 undefined,即使內容是字串也會直接回傳。

陷阱二:局部手動轉型 Number() 的隱患

發現 get<number>() 拿到的仍是字串後,最容易想到的修補方式就是在呼叫端手動轉型:

const port = Number(configService.get('PORT'));

這種寫法看似解決了問題,卻帶來了兩個架構上的缺陷:

  1. 無法阻擋無效設定(產生 NaN):如果 .env 的內容是 PORT=not-a-numberNumber() 轉換後會得到 NaN。程式不會拋出例外,而是帶著這個無效值繼續執行,直到後續綁定 Port 失敗時才爆發出難以追查的 Bug。
  2. 邏輯散落與遺漏:每個呼叫 PORT 的地方都必須記得手動寫 Number(),只要漏寫了,原始字串就會重新滲透進系統。

環境變數作為全域共享的啟動配置,比起在各個呼叫端分散補救,更適合在進入應用程式的載入邊界進行集中轉型與驗證。

常見誤解:全域設定和型別轉換有關嗎?

ConfigModule.forRoot() 常會搭配:

ConfigModule.forRoot({
  isGlobal: true,
  // ...
})

但它和這次的型別問題負責不同的事情:

設定 核心職責
validationSchema 資料驗證、套用預設值與型別轉型
isGlobal 負責 DI 容器的作用域,決定其他模組能否直接注入 ConfigService

只要環境變數通過了 validationSchema 的轉型,無論 isGlobal 設定為 truefalse,都不會改變已轉換好的型別結果。

若其他模組因為沒設定 isGlobal: true 且未手動 imports: [ConfigModule] 而導致報錯,那屬於「拿不拿得到 ConfigService 服務」的注入問題,與「資料是否有被轉型」無關。

總結

  1. 泛型只是編譯期的承諾ConfigService.get<number>('PORT') 裡的 <number> 只改善靜態型別檢查,不會產生執行期轉型程式碼。
  2. 環境變數在 Node.js 中是字串.env 提供文字,載入 process.env 後仍是字串;若應用程式需要 numberboolean 等型別,就必須另外解析與驗證。
  3. 將轉型與驗證集中在載入邊界:不要在每一個 get() 呼叫旁邊寫 Number();設定進入應用程式時,應由 Schema (Joi / Zod) 或是自訂 validate() 統一轉型與驗證。
  4. 配置各司其職isGlobal 決定的是能不能拿到 ConfigService 實體;validationSchema 決定的是拿到的設定是否經過驗證與執行期型別轉換。

參考資料


上一篇
Day 5|錯覺的順序:ConfigService 為什麼注入失敗?別被 imports 陣列順序騙了
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言